iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 22 篇

Day 22: 元件越做越多:整理 CUI Component Contract

  • 分享至 

  • xImage
  •  

做到 Day 21,CUI 已經累積不少東西了。

從最開始的一顆 Button,到現在已經有:

Button
Input
Field
Textarea
Checkbox
Radio
Switch
Select

Badge
Alert
Empty State

Table
Pagination
Skeleton

甚至開始出現由多個元件組成的 Pattern:

Form Pattern
Data List Pattern
Data State

做到這裡,我開始遇到一個跟 Accessibility 沒有直接關係,卻遲早一定會發生的問題:

元件越多,API 越容易開始不一致。

例如:

<Button size="md" />

<Switch size="default" />

咦?

一個叫:

md

另一個叫:

default

再看看狀態:

<Input aria-invalid="true" />

<Field data-invalid="true" />

<Button disabled />

又或者:

<Badge variant="success" />

<Alert variant="destructive" />

這些單獨看都沒有錯。

但當元件越來越多,就會開始出現:

為什麼這顆叫 md?
為什麼那顆叫 default?

error 和 destructive 是同一件事嗎?

invalid 要用 aria-invalid 還是 data-invalid?

data-slot 和 data-cui-slot 又有什麼差別?

如果現在不整理,再過十天,我大概會開始翻自己以前的程式碼:

「等等,我之前到底怎麼命名的?」🤣

所以今天先不做新元件。

來整理 CUI 到目前為止最重要的一件事:

Component Contract


1. Component Contract 是什麼?

以前我會把 UI Component 想成:

Component
=
HTML
+
CSS
+
JavaScript

但做到現在,我覺得還少了一層:

Component
=
Structure
+
API
+
State
+
Semantics
+
Styling Contract

例如一顆 Button:

<Button
  variant="primary"
  size="md"
  disabled
>
  儲存
</Button>

這裡其實已經包含很多規則:

Component
Button

Variant
primary

Size
md

State
disabled

Semantic
<button disabled>

Style Hook
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"

這些規則加起來,就是:

Button Contract

2. 為什麼 CUI 特別需要 Contract?

因為 CUI 一開始就不是只打算給 React 使用。

我們的架構是:

                CUI Source
                    │
             Design Tokens
                    │
           Components / Rules
                    │
                  Build
             ┌──────┴──────┐
             ↓             ↓
           React       CDN Assets
                       CSS / JS
             │             │
             ↓             ↓
       New Systems     Legacy Web

React 可以寫:

<Button
  variant="primary"
  size="md"
>
  儲存
</Button>

但 Legacy 不可能寫 React Component。

它最後可能是:

<button
  data-cui-slot="button"
  data-cui-variant="primary"
  data-cui-size="md"
>
  儲存
</button>

所以真正跨平台共享的東西不是:

React Component

而是:

Component Contract

3. CUI Contract 目前有哪些部分?

整理到目前為止,大致可以拆成:

CUI Component Contract
│
├── Semantic HTML
├── Component API
│   ├── variant
│   └── size
│
├── Native State
│
├── ARIA State
│
├── CUI Data Attributes
│
└── Design Tokens

今天就逐一整理。


4. 第一層:Semantic HTML

第一個原則其實最簡單:

能使用原生 HTML,就先使用原生 HTML。

例如:

<Button />

最後應該是:

<button>

不是:

<div role="button">

Input:

<input>

Textarea:

<textarea>

Table:

<table>
<thead>
<tbody>
<tr>
<th>
<td>

Pagination:

<nav>
<ul>
<li>
<a>

這些原生元素本身就帶有:

Role
Keyboard Behavior
Browser Behavior
Accessibility Semantics

所以 CUI 的第一層 Contract 不是 ARIA。

而是:

HTML

5. ARIA 是補充,不是取代 HTML

例如 Table Header:

<TableHead scope="col">
  姓名
</TableHead>

最後:

<th scope="col">
  姓名
</th>

我們不會寫:

<div role="columnheader">

除非真的有特殊需求。

同樣 Pagination:

<nav aria-label="申請紀錄分頁">

這裡:

nav

負責:

這是一個 Navigation Landmark

而:

aria-label

負責:

這是哪一個 Navigation

所以:

Native HTML
+
ARIA when needed

而不是:

ARIA everywhere

6. 第二層:Variant

接著是目前最常出現的 API:

variant

例如:

<Button variant="primary" />
<Button variant="secondary" />
<Button variant="outline" />
<Button variant="ghost" />
<Button variant="destructive" />
<Button variant="link" />

Badge:

<Badge variant="success" />
<Badge variant="warning" />
<Badge variant="destructive" />

Alert:

<Alert variant="destructive" />

這裡需要先定義:

Variant 是「視覺與用途的變化」,不是 State。

例如:

primary
secondary
outline
ghost
destructive

可以是 Variant。

但:

disabled
focused
invalid
checked

不是 Variant。


7. Variant 和 State 不要混在一起

例如不要設計:

<Button variant="disabled">

而應該:

<Button disabled>

也不要:

<Input variant="invalid" />

而是:

<Input aria-invalid="true" />

因為:

Variant
→ 元件長什麼樣 / 用途

State
→ 元件現在處於什麼狀態

兩者概念不同。

整理成:

variant
├── primary
├── secondary
├── outline
├── ghost
└── destructive

state
├── disabled
├── focus
├── invalid
├── checked
└── selected

8. destructive、error、danger 到底選哪個?

這也是 Design System 很容易混亂的地方。

例如:

danger
error
destructive
negative
critical

其實大家都看過。

目前 CUI 已經沿用 shadcn 的:

destructive

我暫時不打算為了「統一感」全部改掉。

因為它描述的是:

這個 Action / Style 帶有破壞性或危險性。

例如:

<Button variant="destructive">
  刪除帳號
</Button>

而:

error

比較像 Semantic Status。

例如 Design Token:

--cui-color-error
--cui-color-error-container

所以目前可以理解成:

destructive
→ Component Variant

error
→ Semantic Color / Status

兩者相關,但不完全是同一層。


9. 第三層:Size

這次盤點時,我發現 Size 是目前比較容易長歪的地方。

例如 Button 已經使用:

sm
md
lg
icon-sm
icon-md
icon-lg

但有些 shadcn Component 原本可能使用:

sm
default

單獨看沒有問題。

但 CUI 如果要建立自己的 Contract,我比較希望使用一致的尺度:

sm
md
lg

而不是:

small
medium
large

也不是:

sm
default
lg

因此 CUI 的基本 Size Vocabulary 暫定:

sm
md
lg

需要 Icon-only 尺寸時:

icon-sm
icon-md
icon-lg

10. 但不是每個 Component 都一定要有三個 Size

統一 API 不代表:

每顆元件都硬塞 sm / md / lg。

例如 Table:

<Table size="md">

目前根本沒有必要。

Badge 也不一定需要:

<Badge size="lg">

所以原則是:

需要 Size
→ 使用共同 Vocabulary

不需要 Size
→ 不提供 Size API

這比為了形式一致,把所有 Component 都塞滿 Props 更重要。


11. 第四層:Native State

有些 State HTML 本來就知道。

例如:

<button disabled>
<input disabled>
<input type="checkbox" checked>

這些應該優先保留 Native State。

CSS 也可以直接:

:disabled
:checked
:focus-visible

而不是另外發明:

data-cui-disabled="true"

如果原生 HTML 已經知道:

它是 Disabled。

我們沒有必要再告訴它第二次。


12. Focus 也不要自己發明 State

例如:

:hover
:active
:focus-visible

這些本來就是 CSS State。

所以不需要:

data-cui-state="focus"

讓 JavaScript 去同步:

focus
blur
mouseenter
mouseleave

這反而增加出錯機率。

因此:

Native / CSS 能表達
→ 使用 Native / CSS

13. 第五層:ARIA State

有些狀態不是純 CSS,也不是單純視覺。

例如:

invalid
expanded
current
busy

這時 ARIA 本身就是 Contract 的一部分。

我們目前已經用到:

aria-invalid="true"
aria-current="page"
aria-busy="true"

未來還可能碰到:

aria-expanded="true"

這些不只是 Styling Hook。

它們本身有 Accessibility Semantics。


14. 不要為了 CSS 再複製一份 ARIA State

例如已經有:

aria-invalid="true"

CSS 可以:

[aria-invalid="true"] {
  ...
}

就不一定需要再加:

data-cui-invalid="true"

否則可能發生:

aria-invalid="false"
data-cui-invalid="true"

到底誰是真的?🤣

所以:

ARIA 已經能表達
→ 優先直接使用 ARIA

15. 那 data-invalid 又是什麼?

前面的 Field 我們曾經使用:

<Field data-invalid="true">

同時 Input:

<Input aria-invalid="true" />

這不是重複嗎?

其實用途不同。

Input:

aria-invalid
→ 告訴 Assistive Technology:
  這個 Control 的值無效

Field:

data-invalid
→ Parent Styling Hook

例如:

Field
├── Label
├── Input ← aria-invalid
├── Description
└── Error

Parent Field 本身不是 Form Control。

所以:

<Field data-invalid={hasError}>

可以用來控制整組:

Label Color
Error Layout
Spacing

因此這個可以保留。


16. 第六層:data-slot

目前 shadcn 產生的 Component 很常有:

data-slot="button"

或:

data-slot="table-row"

這是 shadcn Component 內部結構的一部分。

而 CUI 又加入:

data-cui-slot="button"

乍看之下:

這不是一模一樣嗎?

目前確實很像。

但兩者責任不同。


17. data-slot 和 data-cui-slot

我目前把它們分成:

data-slot
→ shadcn / React implementation

data-cui-slot
→ CUI public contract

例如:

<button
  data-slot="button"
  data-cui-slot="button"
>

未來 React:

<button
  data-slot="button"
  data-cui-slot="button"
>

Legacy:

<button
  data-cui-slot="button"
>

Legacy 根本不需要知道:

shadcn
Base UI
React

它只需要知道:

CUI

18. 為什麼不直接把 data-slot 改掉?

因為 shadcn 內部可能存在:

[data-slot="..."]

或 Tailwind Selector:

data-[slot=...]

直接全部替換有可能破壞原本 Component 行為。

所以目前比較安全:

data-slot="..."
data-cui-slot="..."

兩個並存。

CUI 不需要為了「看起來比較乾淨」去破壞 upstream implementation。


19. data-cui-variant

如果 Variant 是 CUI Public Contract,那 Legacy 也需要知道。

React:

<Button variant="primary">

輸出的 DOM 可以帶:

<button
  data-cui-slot="button"
  data-cui-variant="primary"
>

Legacy:

<button
  data-cui-slot="button"
  data-cui-variant="primary"
>

這樣未來 CDN CSS 才能:

[data-cui-slot="button"]
[data-cui-variant="primary"] {
  ...
}

React API 和 Legacy HTML 就有共同語言。


20. data-cui-size

Size 也是相同概念。

React:

<Button size="md">

DOM:

<button
  data-cui-slot="button"
  data-cui-size="md"
>

Legacy:

<button
  data-cui-slot="button"
  data-cui-size="md"
>

因此目前 CUI 的核心 Public Data Attributes 可以整理成:

data-cui-slot
data-cui-variant
data-cui-size

不是所有 Component 都需要三個。

但如果有:

Variant
Size

就使用這套命名。


21. 不要建立 data-cui-everything

做到這裡很容易開始興奮:

data-cui-slot
data-cui-size
data-cui-variant
data-cui-disabled
data-cui-focused
data-cui-hovered
data-cui-invalid
data-cui-checked
data-cui-current
data-cui-expanded

然後 DOM 長成聖誕樹 🎄

其實不需要。

優先順序應該是:

1. Native HTML
2. ARIA
3. CSS pseudo-class
4. CUI Data Attribute

只有前三者不能清楚表達 CUI Public Contract 時,再建立自己的 Data Attribute。


22. Design Token 也是 Contract

前面 Day 6 我們建立:

--cui-color-primary
--cui-color-on-primary

--cui-color-surface
--cui-color-on-surface

--cui-color-error
--cui-color-success
--cui-color-warning

這些其實跟:

data-cui-slot

一樣重要。

因為未來不論 React 還是 CDN:

Component
↓
Semantic Token
↓
Primitive Token

例如:

Button Primary
↓
--cui-color-primary
↓
Blue 700

Component 不應該自己知道:

#1d4ed8

它應該知道:

primary

23. Contract 不等於把所有東西固定死

這次整理還有一件很重要的事情:

Contract 是為了建立一致性,不是讓系統不能改。

例如目前:

Button Size
sm / md / lg

未來發現:

xs

真的有需求,還是可以加入。

或者未來:

Badge Variant

需要新增:

info

也不是不能做。

Contract 的意思比較像:

新增以前
先確認既有語言能不能表達

而不是每做一顆元件就發明新的名字。


24. 今天做一次 Component Audit

所以今天沒有:

npx shadcn add ...

而是打開:

src/components/ui/

把目前元件快速盤點一次。

可以整理成:

Component Variant Size State CUI Slot
Button ✓ ✓ disabled ✓
Input — — disabled / invalid ✓
Textarea — — disabled / invalid ✓
Checkbox — — checked / disabled ✓
Radio — — checked / disabled ✓
Switch — ✓ checked / disabled ✓
Select — — expanded / disabled / invalid ✓
Badge ✓ — — ✓
Alert ✓ — — ✓
Table — — — ✓
Pagination — — current ✓
Skeleton — — — ✓

這張表不是 API Specification 的最終版本。

只是第一次:

把我們已經做的東西攤開來看。


25. Audit 時發現不一致,要全部立刻改嗎?

不用。

這點我覺得很重要。

例如我們發現:

Button
size="md"

Switch
size="default"

今天可以先記錄:

Switch size naming should be normalized.

再判斷修改會影響哪些地方。

Design System Governance 不等於:

看到不一致就全域 Search & Replace。

尤其 CUI 還建立在:

shadcn
Base UI
Tailwind

之上。

我們要知道哪些是:

Upstream Implementation

哪些才是:

CUI Public API

26. 我目前想留下的 CUI 基本規則

整理到今天,可以先得到第一版:

CUI Component Contract v0.1

Semantic

Prefer native HTML semantics.
Use ARIA only when needed.

Variant

variant describes visual / functional variation.
State is not variant.

Size

Prefer:
sm
md
lg

Only provide size when the component needs it.

State

Prefer:
Native HTML
ARIA
CSS pseudo-class

before custom data attributes.

Public Hooks

data-cui-slot
data-cui-variant
data-cui-size

Styling

Use semantic Design Tokens.
Avoid hard-coded component colors.

27. React 和 Legacy 的責任也更清楚了

整理完 Contract 後,目前架構可以更精確地寫成:

                    CUI
                     │
        ┌────────────┴────────────┐
        │                         │
 Design Tokens            Component Contract
                                  │
                    ┌─────────────┴─────────────┐
                    │                           │
                  React                       Legacy
                    │                           │
             React Components              HTML + CDN
                    │                           │
             Base UI / shadcn              Vanilla JS

共同的是:

Semantics
Tokens
Naming
data-cui-*
Accessibility Rules

不同的是:

Implementation
Behavior Adapter
Rendering Environment

這比:

React 做一份
Legacy 再做一份

好維護很多。


28. Component Contract 也是給 AI 看的

CUI 最後還有一個目標:

AI Skill

這時 Contract 就更重要。

如果未來 AI 問:

我要建立一個主要操作按鈕

Skill 可以明確知道:

<Button
  variant="primary"
  size="md"
>

而不是 AI 自己猜:

<Button type="main" />

或:

<Button color="blue" />

甚至:

<button className="bg-blue-700 ...">

Contract 越清楚:

Human
AI
React
Legacy

使用同一套 Design System 的機率就越高。


29. Accessibility 也需要 Governance

做 Accessibility 最怕的一件事是:

每個開發者都知道一點點,但每個人的做法都不同。

有人:

aria-label

有人:

title

有人:

role

有人直接:

<div onclick>

最後每個頁面都「好像有做 Accessibility」,但行為完全不同。

Design System 的價值之一,就是把這些決策收斂成:

Button 怎麼做
Field 怎麼做
Error 怎麼做
Pagination 怎麼做
Loading 怎麼做

讓使用者在不同系統裡得到比較一致的體驗。


30. 今天沒有新元件,但其實很重要

今天 Git Diff 可能沒有前幾天精彩。

沒有:

+ 200 lines
+ New Component
+ Cool UI

甚至可能只改:

一些 naming
一些 data-cui attributes
一些 documentation

但 Design System 開發不能永遠只做:

Add Component
Add Component
Add Component
Add Component

做到一定程度就需要:

Audit
Normalize
Document
Govern

不然最後得到的只是:

一個很大的 components 資料夾。

而不是 Design System。


Day 22 完成

今天我們第一次正式整理 CUI 的 Component Contract:

Semantic HTML
        ↓
Component API
        ↓
Variant / Size
        ↓
Native & ARIA State
        ↓
data-cui-* Public Contract
        ↓
Design Tokens

目前最重要的原則可以濃縮成:

HTML 能表達
→ 不重新發明

ARIA 能表達
→ 不複製 State

CSS 能處理
→ 不加 JavaScript

CUI 真的需要跨平台共享
→ 才建立 data-cui-* Contract

這樣未來不管是:

React
Legacy
CDN
AI Skill

大家都能理解同一套 CUI 語言。

而且做到今天,我才真的開始覺得:

CUI 不只是「我做了一些無障礙元件」。

它開始有自己的規則了。


Next:Day 23

整理完 Contract,明天終於可以繼續補元件了 😆

但這次不做 Table、Form 這種大型結構。

我們來補一些在真正系統裡很常出現,卻很容易被忽略的小元件:

Tooltip
Separator
Visually Hidden

尤其 Tooltip 很值得單獨看。

因為一個看起來只是:

Hover → 出現小泡泡

的東西,實際上馬上會遇到:

Keyboard 怎麼開?

Focus 時看得到嗎?

Esc 能不能關?

滑鼠移到 Tooltip 上會不會消失?

Touch Device 怎麼辦?

Tooltip 可以放重要資訊嗎?

Icon-only Button 又該怎麼命名?

小小一顆 Tooltip,Accessibility 問題意外地多。

Day 23:

Tooltip 不只是 Hover:補齊 UI Kit 裡的小型輔助元件


上一篇
# Day 21: 資料列表不只有「有資料」:Loading、Empty、Error
下一篇
Day 23: Tooltip 不只是 Hover:補齊 UI Kit 裡的小型輔助元件
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言